● Zion Boggan
repos/Stepping App/apps/web/src/app/city/[slug]/road/page.tsx
zionboggan.com ↗
213 lines · tsx
History for this file →
1
import Link from "next/link";
2
import { SiteFooter } from "@/components/site-footer";
3
import { notFound } from "next/navigation";
4
import { revalidatePath } from "next/cache";
5
import { TRAVEL_CATEGORY } from "@stepping/types";
6
import { serverCaller } from "@/lib/trpc/server";
7
 
8
export const dynamic = "force-dynamic";
9
 
10
type Category = (typeof TRAVEL_CATEGORY)[number];
11
 
12
const SECTIONS: {
13
  category: Category;
14
  heading: string;
15
  note?: string;
16
  count: string;
17
  badge: string;
18
  empty: string;
19
  field: string;
20
  cta: string;
21
}[] = [
22
  {
23
    category: "hotel",
24
    heading: "Hotels",
25
    note: "The first stop for out-of-towners. Host hotels and places to stay near the sets.",
26
    count: "stays",
27
    badge: "STAY",
28
    empty:
29
      "Hotels will be listed here as events name their host hotels. Add a place to stay below.",
30
    field: "Hotel name",
31
    cta: "Add hotel",
32
  },
33
  {
34
    category: "event_center",
35
    heading: "Event Centers",
36
    count: "venues",
37
    badge: "VENUE",
38
    empty: "Venues will be listed here as they come on board. Add one that hosts sets below.",
39
    field: "Event center name",
40
    cta: "Add event center",
41
  },
42
  {
43
    category: "restaurant",
44
    heading: "Restaurants",
45
    count: "tables",
46
    badge: "EAT",
47
    empty: "Restaurants will be listed here soon. Add a plate worth the trip below.",
48
    field: "Restaurant name",
49
    cta: "Add restaurant",
50
  },
51
  {
52
    category: "mall",
53
    heading: "Shopping",
54
    count: "stops",
55
    badge: "SHOP",
56
    empty: "Shopping stops will be listed here soon. Add the closest one to the set below.",
57
    field: "Mall or shopping district",
58
    cta: "Add shopping",
59
  },
60
  {
61
    category: "attraction",
62
    heading: "Attractions",
63
    count: "sights",
64
    badge: "SEE",
65
    empty: "Attractions will be listed here soon. Add one worth seeing in town below.",
66
    field: "Attraction name",
67
    cta: "Add attraction",
68
  },
69
  {
70
    category: "entertainment",
71
    heading: "Entertainment",
72
    count: "spots",
73
    badge: "PLAY",
74
    empty: "Spots will be listed here soon. Add a lounge, club, or show near the set below.",
75
    field: "Entertainment spot",
76
    cta: "Add entertainment",
77
  },
78
  {
79
    category: "hospital",
80
    heading: "Hospitals",
81
    note: "Just in case. The nearest care to the sets.",
82
    count: "nearest",
83
    badge: "CARE",
84
    empty: "No hospital listed yet. Add the nearest one below, just in case.",
85
    field: "Hospital name",
86
    cta: "Add hospital",
87
  },
88
];
89
 
90
export default async function RoutePage({
91
  params,
92
}: {
93
  params: Promise<{ slug: string }>;
94
}) {
95
  const { slug } = await params;
96
  const caller = serverCaller();
97
  const city = await caller.city.bySlug({ slug });
98
  if (!city) notFound();
99
 
100
  const cityId = city.id;
101
  const grouped = await Promise.all(
102
    SECTIONS.map((s) => caller.travelPlace.list({ cityId, category: s.category })),
103
  );
104
 
105
  async function addPlace(formData: FormData) {
106
    "use server";
107
    const category = String(formData.get("category") ?? "");
108
    const name = String(formData.get("name") ?? "").trim();
109
    const address = String(formData.get("address") ?? "").trim();
110
    if (!name || !TRAVEL_CATEGORY.includes(category as Category)) return;
111
    await serverCaller().travelPlace.create({
112
      cityId: city!.id,
113
      category: category as Category,
114
      name,
115
      address: address || undefined,
116
    });
117
    revalidatePath(`/city/${slug}/road`);
118
  }
119
 
120
  return (
121
    <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8">
122
      <Link href={`/city/${slug}`} className="kicker hover:text-gold">
123
        ← {city.name}
124
      </Link>
125
 
126
      <p className="kicker mt-10 text-ash">The Road · {city.metroLabel}</p>
127
      <h1 className="display mt-3 text-7xl sm:text-8xl">The Road</h1>
128
      <p className="serif mt-5 max-w-xl text-xl text-ash">
129
        In town for a set or a weekender. Hotels first, then the event centers,
130
        food, shopping, attractions, entertainment, and the nearest hospital
131
        just in case. Everything you need to do {city.name} right.
132
      </p>
133
 
134
      {SECTIONS.map((section, idx) => {
135
        const places = grouped[idx];
136
        return (
137
          <section
138
            key={section.category}
139
            className="mt-16 border-t border-[var(--line)] pt-10"
140
          >
141
            <div className="flex items-end justify-between">
142
              <div>
143
                <p className="kicker text-gold">Stop {String(idx + 1).padStart(2, "0")}</p>
144
                <h2 className="display mt-2 text-4xl">{section.heading}</h2>
145
              </div>
146
              <span className="kicker text-ash">
147
                {String(places.length).padStart(2, "0")} {section.count}
148
              </span>
149
            </div>
150
 
151
            {section.note ? (
152
              <p className="serif mt-4 max-w-xl text-lg text-ash">{section.note}</p>
153
            ) : null}
154
 
155
            {places.length === 0 ? (
156
              <p className="serif mt-8 text-xl text-ash">{section.empty}</p>
157
            ) : (
158
              <ul className="mt-6">
159
                {places.map((p, i) => (
160
                  <li
161
                    key={p.id}
162
                    className="flex items-center gap-5 border-b border-[var(--line)] py-6"
163
                  >
164
                    <span className="kicker w-8 text-gold">
165
                      {String(i + 1).padStart(2, "0")}
166
                    </span>
167
                    <div className="flex-1">
168
                      <p className="display text-2xl text-bone sm:text-3xl">
169
                        {p.name}
170
                      </p>
171
                      {p.address ? (
172
                        <p className="kicker mt-1 text-ash">
173
                          {p.address.toUpperCase()}
174
                        </p>
175
                      ) : null}
176
                    </div>
177
                    <span className="kicker border border-[var(--line)] px-3 py-1">
178
                      {section.badge}
179
                    </span>
180
                  </li>
181
                ))}
182
              </ul>
183
            )}
184
 
185
            <form action={addPlace} className="mt-8 flex flex-col gap-3">
186
              <input type="hidden" name="category" value={section.category} />
187
              <input
188
                name="name"
189
                required
190
                placeholder={section.field}
191
                className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold"
192
              />
193
              <div className="mt-2 flex flex-wrap items-center gap-4">
194
                <input
195
                  name="address"
196
                  placeholder="Address (optional)"
197
                  className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold"
198
                />
199
                <button
200
                  type="submit"
201
                  className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
202
                >
203
                  {section.cta}
204
                </button>
205
              </div>
206
            </form>
207
          </section>
208
        );
209
      })}
210
      <SiteFooter />
211
    </main>
212
  );
213
}